import { hasComponent, defineComponent, Types, type IWorld } from './ecs';
import { getGridAt, gridMapOn } from './pos2';
import { getGridAt as getGridAt3 } from './pos3';
import { Cover, Opaque, Size, type Entity } from './components';

export const Eyes = defineComponent('Eyes', {
  range: Types.ui16,
});

let eyeAdditiveModifiers = [];
let eyeCapModifiers = [];

const processEyeAdditiveModifier = (world, e) => (val, {component, modifier}) => {
  if (hasComponent(world, component, e)) {
    return val + modifier(world, e);
  }
  return val;
};

const processEyeCapModifier = (world, e) => (val, {component, modifier}) => {
  if (hasComponent(world, component, e)) {
    return Math.min(val, modifier(world, e));
  }
  return val;
};

export const registerEyeAdditiveModifier = (component, modifier) => {
  eyeAdditiveModifiers.push({component, modifier});
};

export const registerEyeCapModifier = (component, modifier) => {
  eyeCapModifiers.push({component, modifier});
};

/**
 * Get range by eyes.
 * - take eyes range
 * - apply additive changes from temporary effects
 * - apply caps from temporary effects
 */
export const getEyeRange = (world: IWorld, e: Entity) => {
  if (!hasComponent(world, Eyes, e))
    return 0;
  const base = Eyes.range[e];
  const processAdditive = processEyeAdditiveModifier(world, e);
  const afterAdded = eyeAdditiveModifiers.reduce(processAdditive, base);
  const processCap = processEyeCapModifier(world, e);
  return eyeCapModifiers.reduce(processCap, afterAdded);
};

export const processEyes = (world, e) => {
  const range = getEyeRange(world, e);
  const mapPiece = getGridAt(world, e, range);
  return look(world, mapPiece, range);
};

export const processEyes3 = (world, e) => {
  const range = getEyeRange(world, e);
  const mapPiece = getGridAt3(world, e, range);
  return look(world, mapPiece, range);
};

// TODO: make this modular
const filterVisible = (world, stack) => {
  const res = [];
  let coverSize = 0;
  for (let e of stack) {
    if (hasComponent(world, Cover, e)) {
      coverSize = Math.max(Cover.size[e], coverSize);
    }
    if (!hasComponent(world, Size, e)) {
      res.push(e);
    }
  }
  for (let e of stack) {
    if (hasComponent(world, Size, e) && Size.size[e] > coverSize) {
      res.push(e);
    }
  }
  return res;
};

/**
 * Return entities visible by e
 */
export const look = (world, mapPiece, dist) => {
  const bitMap = gridMapOn(mapPiece, -dist, -dist, dist, dist, ({stack}) => {
    const visibleStack = filterVisible(world, stack);
    return {
      stack: visibleStack,
      opaque: stack.some((e) => hasComponent(world, Opaque, e)),
    };
  });
  calculateFov(
    (x, y) => {
      bitMap.get(y).get(x).visible = true;
    },
    (x, y) => bitMap.get(y).get(x).opaque,
    dist,
  );
  return bitMap;
};

/*
 * Calculate field of view
 * Uses functions to query/set fov, so you can use any structure with it
 * The algorithm is as following:
 * - scan progressive concentric squares, tracking lit angles
 * - assume every tile to be circular (radius 0.5 tile)
 * - if any light is hitting that circle, it's considered lit
 */
const calculateFov = (setVisible, isOpaque, dist) => {
  let light = [[0, 2*Math.PI]];
  const check = (x, y) => {
    const r = Math.sqrt(x**2+y**2);
    const halfArc = Math.atan2(0.5, r);
    let middleAngle = Math.atan2(y, x);
    if (middleAngle < 0) {
      middleAngle += 2*Math.PI;
    }
    const angle0 = middleAngle - halfArc;
    const angle1 = middleAngle + halfArc;
    if (angle0 < 0) {
      // wrap around
      checkAngle(x, y, angle0 + 2*Math.PI, 2*Math.PI);
      checkAngle(x, y, 0, angle1);
    } else if (angle1 > 2*Math.PI) {
      // wrap around
      checkAngle(x, y, angle0, 2*Math.PI);
      checkAngle(x, y, 0, angle1 - 2*Math.PI);
    } else {
      // normal
      checkAngle(x, y, angle0, angle1);
    }
  }
  const checkAngle = (x, y, angle0, angle1) => {
    let i = 0; // TODO: start from previous
    while (i < light.length && angle0 > light[i][1])
      ++i;
    if (i >= light.length)
      return;
    if (angle1 < light[i][0])
      return;
    // TODO: set light amount
    // This is a little complicated because it's unclear how to share light between
    // two adjacent opaque tiles. Not deducing any light doesn't work with square
    // scanning because then tiles in a row beyond the same scan-square would get
    // dimmed, looking inconsistent:
    // @ #
    //   # <- this is 100%
    //   # <- this is dimmed
    // so perhaps the most promising approach is to switch to distance scanning
    setVisible(x, y);
    if (isOpaque(x, y)) {
      if (angle0 <= light[i][0]) {
        if (angle1 >= light[i][1]) {
          light.splice(i, 1);
        } else {
          light[i][0] = angle1;
        }
      } else {
        if (angle1 >= light[i][1]) {
          light[i][1] = angle0;
        } else {
          light = [...light.slice(0, i), [light[i][0], angle0], [angle1, light[i][1]], ...light.slice(i+1)];
        }
      }
    }
  };
  setVisible(0, 0);
  for (let r = 1; r <= dist; ++r) {
    let x = -r;
    let y = -r;
    while (x < r) {
      check(x, y);
      ++x;
    }
    while (y < r) {
      check(x, y);
      ++y;
    }
    while (x > -r) {
      check(x, y);
      --x;
    }
    while (y > -r) {
      check(x, y);
      --y;
    }
  }
};
